Skip to content

fix(engine): key PostgreSQL progress to every apply, not one slot - #1130

Merged
aparajon merged 2 commits into
mainfrom
armand/pg-progress-keying
Sep 2, 2026
Merged

fix(engine): key PostgreSQL progress to every apply, not one slot#1130
aparajon merged 2 commits into
mainfrom
armand/pg-progress-keying

Conversation

@aparajon

Copy link
Copy Markdown
Collaborator

One PostgreSQL engine serves a target for its whole lifetime, but it held a single progress slot that each accepted apply took over. A second apply on the same target — a lease handover re-driving work while the previous goroutine is still executing — evicted the first one's state, so the running apply's own driver read the idle sentinel and was told its work no longer exists.

                          before (one slot)        after (keyed per apply)

apply A accepted     →    {key: A, running}        {A: running}
apply B accepted     →    {key: B, running}        {A: running, B: running}
A's driver polls     →    key != A → pending  ✗    A → running  ✓

Progress is now tracked per apply identity, so a poller always reads its own schema change. This mirrors how the sharded engine already isolates progress per operation, rather than leaving PostgreSQL as the one engine where a sibling can answer for you.

Accepting an apply retires the entries that already reached a terminal state, which bounds the map without ever dropping an in-flight change: a terminal entry is kept only so its own driver can read the outcome, and a driver that has accepted another apply on this target has moved past it. Running entries are never retired. Drain still stops tracking everything, and a lookup miss still returns the verbatim idle message that stale-task recovery compares against.

With the eviction gone, the engine can declare SynchronousWorkRegistration: it starts tracking an accepted apply under the engine mutex before Apply returns and executes the statement in a goroutine of this process, so a pending report about work a driver believes is in flight is conclusive rather than a setup phase to wait out. From then on the only writer that stops tracking a running apply is Drain, which means the drive that accepted the work has given it up.

Stacked on #1113, which adds the capability interface.

This PR was written by an agent (Claude Opus 5) on Armand's behalf.

@aparajon
aparajon marked this pull request as ready for review August 26, 2026 02:26
Base automatically changed from armand/poll-lost-work to main August 28, 2026 09:07
One PostgreSQL engine serves a target for its whole lifetime, and it held
a single progress slot that each accepted apply took over. A second apply
on the same target — a lease handover re-driving work while the previous
goroutine still runs — evicted the first one's state, so the running
apply's own driver read the idle sentinel and was told its work no longer
exists. Track every accepted apply under its own identity instead, so a
poller always reads its own schema change.

Accepting an apply retires the entries that already reached a terminal
state, which bounds the map without ever dropping an in-flight change:
a terminal entry is kept only so its driver can read the outcome, and a
driver that has accepted another apply on this target has moved past it.
@aparajon
aparajon force-pushed the armand/pg-progress-keying branch from 648908d to d942449 Compare August 28, 2026 12:04
Copilot AI lite review requested due to automatic review settings August 28, 2026 12:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes PostgreSQL engine progress tracking so concurrent applies on the same long-lived engine instance don’t overwrite each other’s in-memory progress, preventing a running apply from incorrectly observing the idle sentinel (“No active schema change”) when another apply is accepted.

Changes:

  • Replace the single progress “slot” with a map keyed by apply identity (ResumeState.MigrationContext) so each apply reads its own progress.
  • Retire already-terminal progress entries on acceptance of a new apply to bound memory usage without evicting in-flight entries.
  • Expand unit tests to cover concurrent applies, terminal-entry retirement, discard behavior for untracked writes, and drain semantics.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
pkg/engine/postgres/postgres.go Changes the engine’s in-memory progress storage from a single value to a per-apply map and updates drain semantics accordingly.
pkg/engine/postgres/apply.go Keys Progress, claimProgress, and publishProgress by apply identity; adds terminal-entry retirement and updates discard behavior.
pkg/engine/postgres/apply_test.go Adds/updates tests to prove per-apply isolation, bounded retirement, discard-on-untracked, and drain behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@morgo morgo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed on Morgan's behalf by his AI agent. Not approving yet — one question below. Everything else checks out, and this is a clear improvement over the single slot.

What I verified:

  • The map is bounded. claimProgress prunes terminal entries on every accept, so the steady state is {running} ∪ {just-claimed}; terminal entries only accumulate between accepts and the next accept sweeps them. No leak.
  • No resurrection after retirement. A late publishProgress for a pruned key hits the !tracked guard and is discarded rather than re-inserting a dead entry.
  • Drainnil map is safe. e.progress[key] on a nil map returns nil, so Progress takes the idle-sentinel branch, and claimProgress re-makes the map. No nil-map write.
  • The idle sentinel string is untouched, so the verbatim comparison stale-task recovery depends on still matches.

The question — terminal retirement across a lease handover. The retirement rule's justification is "a driver that has accepted another apply on this target has moved past it." That's a statement about one driver's sequencing, but the scenario this PR fixes is explicitly a second driver re-driving work after a lease handover while the first goroutine is still executing.

So: driver A's apply reaches terminal. Driver B accepts a new apply on the same target, which retires A's entry. A polls and gets No active schema change instead of A's actual terminal outcome. Running entries are protected from this; terminal ones are not.

The PR body's answer is that such a poller "settles against the target schema, which is authoritative" — and I think that's probably right, since a terminal outcome is recoverable from the schema in a way that a vanished running apply is not. That asymmetry is what makes the trade sound. But I couldn't confirm from this diff that every path polling a terminal PostgreSQL apply actually reaches that settle-from-schema fallback, rather than surfacing the idle sentinel as "your apply disappeared" — which would be the old bug wearing a different hat.

If you can point at the recovery path that absorbs it (or confirm a retired terminal entry is only ever read by a driver that has already recorded the outcome), I'm happy to stamp. Alternatively, keeping terminal entries until Drain would sidestep the question entirely, at the cost of the bound above — and given the map is small and Drain runs on every recovery/re-plan, that cost looks close to nothing.

Style note, non-blocking: the doc comments here are genuinely good — they explain why the slot changed hands rather than restating the code. Worth keeping that bar.

@morgo morgo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Approved on Morgan's behalf by his AI agent. Resolving my own hold — I asked whether a poller reading the idle sentinel for a retired terminal entry actually reaches the settle-from-schema fallback, and said I couldn't confirm it from the diff. I've now traced it in source. Your answer in the PR body was right, and the mechanism is stronger than the description claims.

The path, end to end:

  1. Progress returns {StatePending, "No active schema change"} for the retired key.
  2. engineReportsLostWork fires, and lostEngineWorkPendingBudget returns 0 for this engine precisely because it declares RegistersWorkSynchronously — so observePending reports exhausted on the first report, with no window in which the driver sits on a stale answer.
  3. settleLostEngineWork then reads the target directly, and its contract is exactly the right one:

A target that already has the desired schema means the work finished and only its outcome was lost: the task completes. A target that still needs the change means the work is genuinely gone: the task is marked retryable so a fresh claim re-drives it — never permanently failed, because nothing about the target is known to be broken.

So the race I was worried about — apply A completes, driver B's accept retires A's terminal entry, A polls for the first time afterwards — lands on schema verification, sees the change present, and completes the task correctly. Not lost, not failed. The retirement is safe.

Worth noting the nice interaction: declaring SynchronousWorkRegistration is what makes this good rather than merely survivable. Without it the driver would spend the full trust budget sitting on an idle sentinel before verifying; with it, verification is immediate. The capability and the retirement rule reinforce each other.

I also checked the one carve-out in settleLostEngineWork — a revert-phase task can't be settled by reading the target, since the forward change already cut over and a schema match proves nothing. That would be a genuine hole in this reasoning, except PostgreSQL doesn't support revert or skip-revert at all (per the envelope doc in #1144), so it isn't reachable for this engine. Worth keeping in mind if PostgreSQL ever grows revert.

Everything from my first review stands: the map is bounded by the prune-on-accept, publishProgress's !tracked guard prevents resurrection, the nil-map read in Progress is safe, and the sentinel string is untouched so the cross-engine contract holds.

@aparajon

aparajon commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Addressing morgo's approval: thank you for tracing the retired-terminal path end to end — your reading matches the intent exactly. The zero trust budget from RegistersWorkSynchronously is what makes retirement-on-accept safe: a poller that reads the idle sentinel for a retired terminal entry verifies against the target schema immediately instead of waiting out a window, and a terminal outcome is always recoverable from the schema in a way a vanished running apply is not. Your note on the revert carve-out is well taken — if PostgreSQL ever grows revert/skip-revert, terminal retirement needs revisiting alongside it, since a revert-phase task can't be settled by a schema read.

Housekeeping since the approval: brought the branch up to date by merging main in (merge, not rebase, so the reviewed history is intact) — no conflicts, and CI is green on the full current suite.

Reply generated by Claude Code (Claude Fable 5).

@aparajon
aparajon merged commit 71b3375 into main Sep 2, 2026
40 of 41 checks passed
@aparajon
aparajon deleted the armand/pg-progress-keying branch September 2, 2026 12:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants